/*
 * The contents of this file are subject to the terms of the Common Development and
 * Distribution License (the License). You may not use this file except in compliance with the
 * License.
 *
 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
 * specific language governing permission and limitations under the License.
 *
 * When distributing Covered Software, include this CDDL Header Notice in each file and include
 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
 * Header, with the fields enclosed by brackets [] replaced by your own identifying
 * information: "Portions Copyright [year] [name of copyright owner]".
 *
 * Copyright 2007-2008 Sun Microsystems, Inc.
 * Portions Copyright 2014-2016 ForgeRock AS.
 */
package com.forgerock.opendj.cli;

import static com.forgerock.opendj.cli.CliMessages.*;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;

import org.forgerock.i18n.LocalizableMessage;

/**
 * An interface for incrementally building a command-line menu.
 *
 * @param <T>
 *            The type of value returned by the call-backs. Use <code>Void</code> if the call-backs do not return a
 *            value.
 */
public final class MenuBuilder<T> {

    /**
     * A simple menu option call-back which is a composite of zero or more underlying call-backs.
     *
     * @param <T>
     *            The type of value returned by the call-back.
     */
    private static final class CompositeCallback<T> implements MenuCallback<T> {

        /** The list of underlying call-backs. */
        private final Collection<MenuCallback<T>> callbacks;

        /**
         * Creates a new composite call-back with the specified set of call-backs.
         *
         * @param callbacks
         *            The set of call-backs.
         */
        public CompositeCallback(Collection<MenuCallback<T>> callbacks) {
            this.callbacks = callbacks;
        }

        @Override
        public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
            List<T> values = new ArrayList<>();
            for (MenuCallback<T> callback : callbacks) {
                MenuResult<T> result = callback.invoke(app);
                if (!result.isSuccess()) {
                    // Throw away all the other results.
                    return result;
                }
                values.addAll(result.getValues());
            }
            return MenuResult.success(values);
        }
    }

    /**
     * Underlying menu implementation generated by this menu builder.
     *
     * @param <T>
     *            The type of value returned by the call-backs. Use <code>Void</code> if the call-backs do not return a
     *            value.
     */
    private static final class MenuImpl<T> implements Menu<T> {

        /** Indicates whether the menu will allow selection of multiple numeric options. */
        private final boolean allowMultiSelect;

        /** The application console. */
        private final ConsoleApplication app;

        /** The call-back lookup table. */
        private final Map<String, MenuCallback<T>> callbacks;

        /** The char options table builder. */
        private final TableBuilder cbuilder;

        /** The call-back for the optional default action. */
        private final MenuCallback<T> defaultCallback;
        /** The description of the optional default action. */
        private final LocalizableMessage defaultDescription;

        /** The numeric options table builder. */
        private final TableBuilder nbuilder;

        /** The table printer. */
        private final TablePrinter printer;

        /** The menu prompt. */
        private final LocalizableMessage prompt;

        /** The menu title. */
        private final LocalizableMessage title;

        /**
         * The maximum number of times we display the menu if the user provides
         * bad input (-1 for unlimited).
         */
        private int nMaxTries;

        /** Private constructor. */
        private MenuImpl(ConsoleApplication app, LocalizableMessage title, LocalizableMessage prompt,
                TableBuilder ntable, TableBuilder ctable, TablePrinter printer, Map<String, MenuCallback<T>> callbacks,
                boolean allowMultiSelect, MenuCallback<T> defaultCallback, LocalizableMessage defaultDescription,
                int nMaxTries) {
            this.app = app;
            this.title = title;
            this.prompt = prompt;
            this.nbuilder = ntable;
            this.cbuilder = ctable;
            this.printer = printer;
            this.callbacks = callbacks;
            this.allowMultiSelect = allowMultiSelect;
            this.defaultCallback = defaultCallback;
            this.defaultDescription = defaultDescription;
            this.nMaxTries = nMaxTries;
        }

        @Override
        public MenuResult<T> run() throws ClientException {
            // The validation call-back which will be used to determine the
            // action call-back.
            ValidationCallback<MenuCallback<T>> validator = new ValidationCallback<MenuCallback<T>>() {

                @Override
                public MenuCallback<T> validate(ConsoleApplication app, String input) {
                    String ninput = input.trim();

                    if (ninput.length() == 0) {
                        if (defaultCallback != null) {
                            return defaultCallback;
                        } else if (allowMultiSelect) {
                            app.println();
                            app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
                            app.println();
                            return null;
                        } else {
                            app.println();
                            app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
                            app.println();
                            return null;
                        }
                    } else if (allowMultiSelect) {
                        // Use a composite call-back to collect all the results.
                        List<MenuCallback<T>> cl = new ArrayList<>();
                        for (String value : ninput.split(",")) {
                            // Make sure that there are no duplicates.
                            String nvalue = value.trim();
                            Set<String> choices = new HashSet<>();

                            if (choices.contains(nvalue)) {
                                app.println();
                                app.println(ERR_MENU_BAD_CHOICE_MULTI_DUPE.get(value));
                                app.println();
                                return null;
                            } else if (!callbacks.containsKey(nvalue)) {
                                app.println();
                                app.println(ERR_MENU_BAD_CHOICE_MULTI.get());
                                app.println();
                                return null;
                            } else {
                                cl.add(callbacks.get(nvalue));
                                choices.add(nvalue);
                            }
                        }

                        return new CompositeCallback<>(cl);
                    } else if (!callbacks.containsKey(ninput)) {
                        app.println();
                        app.println(ERR_MENU_BAD_CHOICE_SINGLE.get());
                        app.println();
                        return null;
                    } else {
                        return callbacks.get(ninput);
                    }
                }
            };

            // Determine the correct choice prompt.
            LocalizableMessage promptMsg;
            if (allowMultiSelect) {
                if (defaultDescription != null) {
                    promptMsg = INFO_MENU_PROMPT_MULTI_DEFAULT.get(defaultDescription);
                } else {
                    promptMsg = INFO_MENU_PROMPT_MULTI.get();
                }
            } else {
                if (defaultDescription != null) {
                    promptMsg = INFO_MENU_PROMPT_SINGLE_DEFAULT.get(defaultDescription);
                } else {
                    promptMsg = INFO_MENU_PROMPT_SINGLE.get();
                }
            }

            // If the user selects help then we need to loop around and
            // display the menu again.
            while (true) {
                // Display the menu.
                if (title != null) {
                    app.println(title);
                    app.println();
                }

                if (prompt != null) {
                    app.println(prompt);
                    app.println();
                }

                if (nbuilder.getTableHeight() > 0) {
                    nbuilder.print(printer);
                    app.println();
                }

                if (cbuilder.getTableHeight() > 0) {
                    TextTablePrinter cprinter = new TextTablePrinter(app.getErrorStream());
                    cprinter.setDisplayHeadings(false);
                    int sz = String.valueOf(nbuilder.getTableHeight()).length() + 1;
                    cprinter.setIndentWidth(4);
                    cprinter.setColumnWidth(0, sz);
                    cprinter.setColumnWidth(1, 0);
                    cbuilder.print(cprinter);
                    app.println();
                }

                // Get the user's choice.
                MenuCallback<T> choice;
                if (nMaxTries != -1) {
                    choice = app.readValidatedInput(promptMsg, validator, nMaxTries);
                } else {
                    choice = app.readValidatedInput(promptMsg, validator);
                }

                // Invoke the user's selected choice.
                MenuResult<T> result = choice.invoke(app);

                // Determine if the help needs to be displayed, display it and start again.
                if (!result.isAgain()) {
                    return result;
                }
                app.println();
                app.println();
            }
        }
    }

    /**
     * A simple menu option call-back which does nothing but return the provided menu result.
     *
     * @param <T>
     *            The type of result returned by the call-back.
     */
    private static final class ResultCallback<T> implements MenuCallback<T> {

        /** The result to be returned by this call-back. */
        private final MenuResult<T> result;

        /** Private constructor. */
        private ResultCallback(MenuResult<T> result) {
            this.result = result;
        }

        @Override
        public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
            return result;
        }

        @Override
        public String toString() {
            return getClass().getSimpleName() + "(result=" + result + ")";
        }
    }

    /** The multiple column display threshold. */
    private int threshold = -1;

    /** Indicates whether the menu will allow selection of multiple numeric options. */
    private boolean allowMultiSelect;

    /** The application console. */
    private final ConsoleApplication app;

    /** The char option call-backs. */
    private final List<MenuCallback<T>> charCallbacks = new ArrayList<>();

    /** The char option keys (must be single-character messages). */
    private final List<LocalizableMessage> charKeys = new ArrayList<>();
    /** The synopsis of char options. */
    private final List<LocalizableMessage> charSynopsis = new ArrayList<>();

    /** Optional column headings. */
    private final List<LocalizableMessage> columnHeadings = new ArrayList<>();
    /** Optional column widths. */
    private final List<Integer> columnWidths = new ArrayList<>();

    /** The call-back for the optional default action. */
    private MenuCallback<T> defaultCallback;
    /** The description of the optional default action. */
    private LocalizableMessage defaultDescription;

    /** The numeric option call-backs. */
    private final List<MenuCallback<T>> numericCallbacks = new ArrayList<>();
    /** The numeric option fields. */
    private final List<List<LocalizableMessage>> numericFields = new ArrayList<>();

    /** The menu title. */
    private LocalizableMessage title;
    /** The menu prompt. */
    private LocalizableMessage prompt;

    /**
     * The maximum number of times that we allow the user to provide an invalid
     * answer (-1 if unlimited).
     */
    private int nMaxTries = -1;

    /**
     * Creates a new menu.
     *
     * @param app
     *            The application console.
     */
    public MenuBuilder(ConsoleApplication app) {
        this.app = app;
    }

    /**
     * Creates a "back" menu option. When invoked, this option will return a {@code MenuResult.cancel()} result.
     *
     * @param isDefault
     *            Indicates whether this option should be made the menu default.
     */
    public void addBackOption(boolean isDefault) {
        addCharOption(INFO_MENU_OPTION_BACK_KEY.get(), INFO_MENU_OPTION_BACK.get(), MenuResult.<T> cancel());

        if (isDefault) {
            setDefault(INFO_MENU_OPTION_BACK_KEY.get(), MenuResult.<T> cancel());
        }
    }

    /**
     * Creates a "cancel" menu option. When invoked, this option will return a {@code MenuResult.cancel()} result.
     *
     * @param isDefault
     *            Indicates whether this option should be made the menu default.
     */
    public void addCancelOption(boolean isDefault) {
        addCharOption(INFO_MENU_OPTION_CANCEL_KEY.get(), INFO_MENU_OPTION_CANCEL.get(), MenuResult.<T> cancel());

        if (isDefault) {
            setDefault(INFO_MENU_OPTION_CANCEL_KEY.get(), MenuResult.<T> cancel());
        }
    }

    /**
     * Adds a menu choice to the menu which will have a single letter as its key.
     *
     * @param c
     *            The single-letter message which will be used as the key for this option.
     * @param description
     *            The menu option description.
     * @param callback
     *            The call-back associated with this option.
     */
    public void addCharOption(LocalizableMessage c, LocalizableMessage description, MenuCallback<T> callback) {
        charKeys.add(c);
        charSynopsis.add(description);
        charCallbacks.add(callback);
    }

    /**
     * Adds a menu choice to the menu which will have a single letter as its key and which returns the provided result.
     *
     * @param c
     *            The single-letter message which will be used as the key for this option.
     * @param description
     *            The menu option description.
     * @param result
     *            The menu result which should be returned by this menu choice.
     */
    public void addCharOption(LocalizableMessage c, LocalizableMessage description, MenuResult<T> result) {
        addCharOption(c, description, new ResultCallback<T>(result));
    }

    /**
     * Creates a "help" menu option which will use the provided help call-back to display help relating to the other
     * menu options. When the help menu option is selected help will be displayed and then the user will be shown the
     * menu again and prompted to enter a choice.
     *
     * @param callback
     *            The help call-back.
     */
    public void addHelpOption(final HelpCallback callback) {
        MenuCallback<T> wrapper = new MenuCallback<T>() {

            @Override
            public MenuResult<T> invoke(ConsoleApplication app) throws ClientException {
                app.println();
                callback.display(app);
                return MenuResult.again();
            }

        };

        addCharOption(INFO_MENU_OPTION_HELP_KEY.get(), INFO_MENU_OPTION_HELP.get(), wrapper);
    }

    /**
     * Adds a menu choice to the menu which will have a numeric key.
     *
     * @param description
     *            The menu option description.
     * @param callback
     *            The call-back associated with this option.
     * @param extraFields
     *            Any additional fields associated with this menu option.
     * @return Returns the number associated with menu choice.
     */
    public int addNumberedOption(LocalizableMessage description, MenuCallback<T> callback,
            LocalizableMessage... extraFields) {
        List<LocalizableMessage> fields = new ArrayList<>();
        fields.add(description);
        if (extraFields != null) {
            fields.addAll(Arrays.asList(extraFields));
        }

        numericFields.add(fields);
        numericCallbacks.add(callback);

        return numericCallbacks.size();
    }

    /**
     * Adds a menu choice to the menu which will have a numeric key and which returns the provided result.
     *
     * @param description
     *            The menu option description.
     * @param result
     *            The menu result which should be returned by this menu choice.
     * @param extraFields
     *            Any additional fields associated with this menu option.
     * @return Returns the number associated with menu choice.
     */
    public int addNumberedOption(LocalizableMessage description, MenuResult<T> result,
            LocalizableMessage... extraFields) {
        return addNumberedOption(description, new ResultCallback<T>(result), extraFields);
    }

    /**
     * Creates a "quit" menu option. When invoked, this option will return a {@code MenuResult.quit()} result.
     */
    public void addQuitOption() {
        addCharOption(INFO_MENU_OPTION_QUIT_KEY.get(), INFO_MENU_OPTION_QUIT.get(), MenuResult.<T> quit());
    }

    /**
     * Sets the flag which indicates whether the menu will permit multiple numeric options to be selected at
     * once. Users specify multiple choices by separating them with a comma. The default is <code>false</code>.
     *
     * @param allowMultiSelect
     *            Indicates whether the menu will permit multiple numeric options to be selected at once.
     */
    public void setAllowMultiSelect(boolean allowMultiSelect) {
        this.allowMultiSelect = allowMultiSelect;
    }

    /**
     * Sets the optional column headings. The column headings will be displayed above the menu options.
     *
     * @param headings
     *            The optional column headings.
     */
    public void setColumnHeadings(LocalizableMessage... headings) {
        this.columnHeadings.clear();
        if (headings != null) {
            this.columnHeadings.addAll(Arrays.asList(headings));
        }
    }

    /**
     * Sets the optional column widths. A value of zero indicates that the column should be expandable, a value of
     * <code>null</code> indicates that the column should use its default width.
     *
     * @param widths
     *            The optional column widths.
     */
    public void setColumnWidths(Integer... widths) {
        this.columnWidths.clear();
        if (widths != null) {
            this.columnWidths.addAll(Arrays.asList(widths));
        }
    }

    /**
     * Sets the optional default action for this menu. The default action call-back will be invoked if the user does not
     * specify an option and just presses enter.
     *
     * @param description
     *            A short description of the default action.
     * @param callback
     *            The call-back associated with the default action.
     */
    public void setDefault(LocalizableMessage description, MenuCallback<T> callback) {
        defaultCallback = callback;
        defaultDescription = description;
    }

    /**
     * Sets the optional default action for this menu. The default action call-back will be invoked if the user does not
     * specify an option and just presses enter.
     *
     * @param description
     *            A short description of the default action.
     * @param result
     *            The menu result which should be returned by default.
     */
    public void setDefault(LocalizableMessage description, MenuResult<T> result) {
        setDefault(description, new ResultCallback<T>(result));
    }

    /**
     * Sets the number of numeric options required to trigger multiple-column display. A negative value (the default)
     * indicates that the numeric options will always be displayed in a single column. A value of 0 indicates that
     * numeric options will always be displayed in multiple columns.
     *
     * @param threshold
     *            The number of numeric options required to trigger multiple-column display.
     */
    public void setMultipleColumnThreshold(int threshold) {
        this.threshold = threshold;
    }

    /**
     * Sets the optional menu prompt. The prompt will be displayed above the menu. Menus do not have a prompt by
     * default.
     *
     * @param prompt
     *            The menu prompt, or <code>null</code> if there is not prompt.
     */
    public void setPrompt(LocalizableMessage prompt) {
        this.prompt = prompt;
    }

    /**
     * Sets the optional menu title. The title will be displayed above the menu prompt. Menus do not have a title by
     * default.
     *
     * @param title
     *            The menu title, or <code>null</code> if there is not title.
     */
    public void setTitle(LocalizableMessage title) {
        this.title = title;
    }

    /**
     * Creates a menu from this menu builder.
     *
     * @return Returns the new menu.
     */
    public Menu<T> toMenu() {
        TableBuilder nbuilder = new TableBuilder();
        Map<String, MenuCallback<T>> callbacks = new HashMap<>();

        // Determine whether multiple columns should be used for numeric options
        boolean useMultipleColumns = threshold >= 0 && numericCallbacks.size() >= threshold;

        // Create optional column headers.
        if (!columnHeadings.isEmpty()) {
            appendHeadings(nbuilder);

            if (useMultipleColumns) {
                appendHeadings(nbuilder);
            }
        }

        // Add the numeric options first.
        int sz = numericCallbacks.size();
        int rows = sz;

        if (useMultipleColumns) {
            // Display in two columns the first column should contain half
            // the options. If there are an odd number of columns then the
            // first column should contain an additional option (e.g. if
            // there are 23 options, the first column should contain 12
            // options and the second column 11 options).
            rows /= 2;
            rows += sz % 2;
        }

        for (int i = 0, j = rows; i < rows; i++, j++) {
            nbuilder.startRow();
            appendCells(nbuilder, i);

            callbacks.put(String.valueOf(i + 1), numericCallbacks.get(i));

            // Second column.
            if (useMultipleColumns && j < sz) {
                appendCells(nbuilder, j);

                callbacks.put(String.valueOf(j + 1), numericCallbacks.get(j));
            }
        }

        // Add the char options last.
        TableBuilder cbuilder = new TableBuilder();
        for (int i = 0; i < charCallbacks.size(); i++) {
            char c = charKeys.get(i).charAt(0);
            LocalizableMessage option = INFO_MENU_CHAR_OPTION.get(c);

            cbuilder.startRow();
            cbuilder.appendCell(option);
            cbuilder.appendCell(charSynopsis.get(i));

            callbacks.put(String.valueOf(c), charCallbacks.get(i));
        }

        // Configure the table printer.
        TextTablePrinter printer = new TextTablePrinter(app.getErrorStream());

        boolean hasHeadings = !columnHeadings.isEmpty();
        printer.setDisplayHeadings(hasHeadings);
        if (hasHeadings) {
            printer.setHeadingSeparatorStartColumn(1);
        }

        printer.setIndentWidth(4);
        if (columnWidths.isEmpty()) {
            printer.setColumnWidth(1, 0);
            if (useMultipleColumns) {
                printer.setColumnWidth(3, 0);
            }
        } else {
            for (int i = 0; i < columnWidths.size(); i++) {
                Integer j = columnWidths.get(i);
                if (j != null) {
                    // Skip the option key column.
                    printer.setColumnWidth(i + 1, j);

                    if (useMultipleColumns) {
                        printer.setColumnWidth(i + 2 + columnWidths.size(), j);
                    }
                }
            }
        }

        return new MenuImpl<>(app, title, prompt, nbuilder, cbuilder, printer, callbacks, allowMultiSelect,
                defaultCallback, defaultDescription, nMaxTries);
    }

    private void appendCells(TableBuilder nbuilder, int i) {
        nbuilder.appendCell(INFO_MENU_NUMERIC_OPTION.get(i + 1));
        for (LocalizableMessage field : numericFields.get(i)) {
            if (field != null) {
                nbuilder.appendCell(field);
            } else {
                nbuilder.appendCell();
            }
        }
    }

    private void appendHeadings(TableBuilder nbuilder) {
        nbuilder.appendHeading();
        for (LocalizableMessage heading : columnHeadings) {
            if (heading != null) {
                nbuilder.appendHeading(heading);
            } else {
                nbuilder.appendHeading();
            }
        }
    }

    /**
     * Sets the maximum number of tries that the user can provide an invalid value in the menu. -1 for unlimited tries
     * (the default). If this limit is reached a ClientException will be thrown.
     *
     * @param nTries
     *            the maximum number of tries.
     */
    public void setMaxTries(int nTries) {
        nMaxTries = nTries;
    }
}
